-
Notifications
You must be signed in to change notification settings - Fork 3.5k
[video_player] Move most Android event logic to Dart #10128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
[video_player] Move most Android event logic to Dart #10128
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This pull request is a significant and well-executed refactoring that moves Android video event logic to Dart, improving type safety by adopting Pigeon event channels and switching to Kotlin for code generation. The changes simplify the native Android code by sending more raw events to be interpreted by the Dart side. Additionally, it improves the buffer progress update logic to be more reliable.
I've found two critical fall-through bugs in the new Dart event handling logic that would cause compilation errors, and a medium-severity issue related to the robustness of the new buffer polling timer. My review comments provide suggestions to fix these issues.
packages/video_player/video_player_android/lib/src/android_video_player.dart
Show resolved
Hide resolved
packages/video_player/video_player_android/lib/src/android_video_player.dart
Show resolved
Hide resolved
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), ( | ||
Timer timer, | ||
) async { | ||
_updateBufferPosition(await _api.getBufferedPosition()); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Timer.periodic
with an async
callback can lead to a few issues. If getBufferedPosition()
takes longer than one second, another call will be initiated before the first one completes, causing overlapping executions. Additionally, if dispose()
is called while getBufferedPosition()
is in flight, _updateBufferPosition
could be called after the player instance has been disposed.
A more robust pattern is to use a recursive Timer
that schedules the next poll only after the previous one completes. For a minimal fix that addresses the use-after-dispose issue, I've added checks for _isDisposed
before and after the await
.
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), ( | |
Timer timer, | |
) async { | |
_updateBufferPosition(await _api.getBufferedPosition()); | |
}); | |
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), ( | |
Timer timer, | |
) async { | |
if (_isDisposed) { | |
timer.cancel(); | |
return; | |
} | |
final int position = await _api.getBufferedPosition(); | |
if (_isDisposed) { | |
return; | |
} | |
_updateBufferPosition(position); | |
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
getBufferedPosition()
takes longer than one second, another call will be initiated before the first one completes
I'm fine with potential bunching up of events if the main thread gets stuck for a second.
Additionally, if
dispose()
is called whilegetBufferedPosition()
is in flight,_updateBufferPosition
could be called after the player instance has been disposed.
Fixed.
if (surfaceProducerHandlesCropAndRotation) { | ||
// When the SurfaceTexture backend for Impeller is used, the preview should already | ||
// be correctly rotated. | ||
rotationCorrection = RotationDegrees.ROTATE_0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a drive-by cleanup of a warning that the IDE linter (but not the command-line linter we have in CI) gave about setting this to the value it already had.
events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees()); | ||
} | ||
|
||
private RotationDegrees getRotationCorrectionFromUnappliedRotation( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was dead code missed when we dropped support for older API versions.
final class QueuingEventSink implements EventChannel.EventSink { | ||
private EventChannel.EventSink delegate; | ||
final class QueuingEventSink { | ||
private PigeonEventSink<PlatformVideoEvent> delegate; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't bother to template the class since this is the only case we use. In the unlikely event that we need other types later, we can always adjust then.
|
||
@Override | ||
public void setLooping(@NonNull Boolean looping) { | ||
public void setLooping(boolean looping) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These minor type changes are because the Kotlin generator and Java generator don't produce identical interfaces.
} | ||
|
||
@Override | ||
public @NonNull Messages.PlaybackState getPlaybackState() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This multi-return getter was just to support the combined polling hack that is replaced by the timer.
} | ||
|
||
@Test | ||
public void onPlaybackStateChangedIdleAfterBufferingSendsBufferingEnd() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are fewer tests here now because the logic this was testing moved to Dart.
} | ||
|
||
@Test | ||
public void onInitializedIncludesRotationCorrectIfNonZero() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the typed version, rotation is always sent, so there's no need to test with and without rotation differently.
const int playerId = 1; | ||
const String mockChannel = 'flutter.io/videoPlayer/videoEvents$playerId'; | ||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger | ||
.setMockMessageHandler(mockChannel, (ByteData? message) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than one giant test, which was hard to understand and maintain, I broke it apart into individual tests for each event/behavior being tested.
Rather than interpreting ExoPlayer event callbacks in Java and then sending event channel events that match the Dart platform interface, send the events directly and intepret them in Dart. This continues the process of reducing Java-side logic in the plugin.
In order to make this change less error-prone, it also adopts Pigeon event channels, so that the new events are type-safe; in order to do that, this switches to the Kotlin Pigeon generator, since Pigeon event channels haven't been implemented for Java.
Now that the buffer progress event synthesis is in Dart, this also fixes an existing TODO by reworking that logic to be based on an independent timer, rather than driven by playback position polling from the app-facing package. This both makes the logic clearer, and makes it work regardless of whether or not the video is paused.
Part of flutter/flutter#172763
Pre-Review Checklist
[shared_preferences]
pubspec.yaml
with an appropriate new version according to the pub versioning philosophy, or I have commented below to indicate which version change exemption this PR falls under1.CHANGELOG.md
to add a description of the change, following repository CHANGELOG style, or I have commented below to indicate which CHANGELOG exemption this PR falls under1.///
).Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2 ↩3